<?php

// set the include path

define("ExceptionWatcherDir", dirname(__FILE__));
define("ExceptionWatcherViewDir", ExceptionWatcherDir . '/views');
ini_set('include_path', ini_get('include_path') . ":" . ExceptionWatcherDir . DIRECTORY_SEPARATOR . 'web');
ini_set('include_path', ini_get('include_path') . ":" . ExceptionWatcherDir . DIRECTORY_SEPARATOR . 'models');

/**
 * A exception notification extension for Yii
 *
 * @author reeze <xiaxuhong@baidu.com>
 * @require > Yii 1.0
 * @see Rails's plugin ExceptionNotification
 */
class ExceptionWatcher extends CApplicationComponent
{
    const VERSION = "0.0.1";

    /**
     * switch on and off the notification
     */
    public $enable = true;

    /**
     * Used for debug myself
     */
    public $debug = false;

    public $exception_recipients = array();

    /**
     * Notify email's title prefix
     */
    public $email_prefix = '';

    public $email_from = null;

    public $max_notify_num = 20;

    /**
     * User can choose to ignore http status eg: 404
     */
    public $ignoreStatuses = array();

    /**
     * the same exception may thown many times
     */
    public $ignoreDuplicate = true;

    /**
     * turn off notification for certern exceptions listed
     */
    public $ignoreExceptions = array();

    /**
     * set where to put the exception db file
     */
    public $dbFileDir = null;

    public $dbFileName = 'exception-watcher.sqlite.db';

    /**
     * we can access the exceptions via web
     */
    public $enableWebInterface = true;

    /**
     * web can override the default layout, if the project's folder struct
     * changed, we have to set the layout or it will naked without layout
     */
    public $layout = "application.extensions.exception_notification.web.views.layout";

    /**
     * User can custom access controll callback
     * $aclCallback must return true or false to allow user access
     * the exceptions captured
     */
    public $aclCallback = null; // default ip?


    public $extra_params_callback = null;


    public $extra_params = array();

    public $autoCreateExceptionTable = true;

    /**
     * User can custom the controller name to avoid name confict
     * @notice admin setting controller will use {$controllerName}_admin
     */
    public $controllerName = 'exceptions';

    private $_initlized = false;

    public function init()
    {
        parent::init();

        if (!$this->enable) {
            return;
        }

        if (empty($this->exception_recipients)) {
            throw new ExceptionWatcherException("Component:ExceptionWatcher's exception recipients field can't be blank, please set in config's component setting");
        }

        Yii::app()->onException = array($this, "handle");
        Yii::app()->onError = array($this, "handle");

        if ($this->enableWebInterface && (Yii::app() instanceof CWebApplication)) {
            $old_map = Yii::app()->controllerMap;
            $map = array(
                $this->controllerName => array(
                    'class' => 'ExceptionViewerController'
                ),
                $this->controllerName . '_admin' => array(
                    'class' => 'ExceptionAdminController'
                )
            );

            Yii::app()->controllerMap = array_merge($old_map, $map);
            Yii::app()->assetManager->publish(dirname(__FILE__) . '/web/assets');
        }

        $this->setUpDb();
    }

    // Mannally notify the exception
    public function notify_exception($exception, $extra_msg = '')
    {
        $this->handle(new CExceptionEvent($this, $exception), $extra_msg);
    }

    public function add_extra_param($params)
    {
        if (is_array($params)) {
            $this->extra_params = array_merge($this->extra_params, $params);
        }
    }

    public function handle($event, $extra_msg = '')
    {
        try {
            $extra_params = array();
            if (is_callable($this->extra_params_callback)) {
                try {
                    $extra_params = call_user_func($this->extra_params_callback);
                } catch (Exception $e) {
                    $extra_params = array("回调函数:{$this->extra_params_callback}调用失败，原因" => $e->getMessage());
                }
            }

            $extra_params = array_merge($extra_params, $this->extra_params);

            if (Yii::app() instanceof CWebApplication) {
                $request = Yii::app()->getRequest();

                $request_type = $request->getRequestType() . ($request->getIsAjaxRequest() ? "(Ajax)" : '');
                $extra_params['REQUEST_URI'] = $_SERVER['REQUEST_URI'] . " - $request_type";
                $params = array_merge($_GET, $_POST);

                if (count($params)) {
                    $extra_params['HTTP_PARAMS'] = array_merge($_GET, $_POST); // no route params extract
                }
            }

            $view_params = array('params' => $extra_params);

            if ($event instanceof CErrorEvent) {
                $type = 'error';

                $message = $event->message;
                $file = $event->file;
                $line = $event->line;


                $view_params['code'] = $event->code;
                $view_params['message'] = $event->message . $extra_msg;
                $view_params['file'] = $event->file;
                $view_params['line'] = $event->line;
                $subject = "PHP Error:" . $event->message;

                $class = "PHP Error";

                $trace = debug_backtrace();
                if (count($trace) > 3)
                    $trace = array_slice($trace, 3);
                $traceString = '';
                foreach ($trace as $i => $t) {
                    if (!isset($t['file']))
                        $t['file'] = 'unknown';
                    if (!isset($t['line']))
                        $t['line'] = 0;
                    if (!isset($t['function']))
                        $t['function'] = 'unknown';
                    $traceString .= "#$i {$t['file']}({$t['line']}): ";
                    if (isset($t['object']) && is_object($t['object']))
                        $traceString .= get_class($t['object']) . '->';
                    $traceString .= "{$t['function']}()\n";
                }

                $view_params['traceString'] = $traceString;
            } else {
                $type = 'exception';

                $message = $event->exception->getMessage() . " " . $extra_msg;
                $file = $event->exception->getFile();
                $line = $event->exception->getLine();
                $traceString = $event->exception->getTraceAsString();

                $view_params['exception'] = $event->exception;
                $view_params['message'] = $message;
                $view_params['is_mannaul'] = $extra_msg ? true : false; // I can identify by this.
                $subject = get_class($event->exception) . ":" . $message;

                $class = get_class($event->exception);
            }


            // save the exception to db
            $num = $this->saveException($class, $message, $file, $line, $traceString);

            if ($this->needNotify($num)) {
                if ($num != 1) {
                    $view_params['params']['<u>该异常抛出</u>'] = $num . "次";
                }
                $this->mail($subject, $this->renderFile($type, $view_params));
            }
            Yii::app()->end();
        } catch (Exception $e) {
            if ($this->debug) {
                throw $e;
            }
        }
    }

    private function getPDO()
    {
        //$dsn = 'sqlite:' . $this->dbFileDir . DIRECTORY_SEPARATOR . $this->dbFileName;	
        //return new PDO($dsn);
        return new PDO(RMS_DB_CON_STRING, RMS_DB_USER_STRING, RMS_DB_PW_STRING);
    }

    private function saveException($class, $message, $file, $line, $traceString)
    {
        $pdo = $this->getPDO();
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $pdo->exec('set names utf8');

        // find if the exeption have thrown yet
        $stmt = $pdo->prepare('SELECT * FROM exceptions where class=:c AND message=:m AND file=:f AND line=:l AND backtrace=:b');
        $stmt->bindParam(':c', $class, PDO::PARAM_STR);
        $stmt->bindParam(':m', $message, PDO::PARAM_STR);
        $stmt->bindParam(':f', $file, PDO::PARAM_STR);
        $stmt->bindParam(':l', $line, PDO::PARAM_INT);
        $stmt->bindParam(':b', $traceString, PDO::PARAM_STR);
        $stmt->execute();

        $result = $stmt->fetch(PDO::FETCH_ASSOC);

        $time = time();

        if ($result) {
            // update the num and last found
            $num = $result['thrown_num'] + 1;
            $stmt = $pdo->prepare('UPDATE exceptions SET last_throw_at=:last, thrown_num=:n where id=:id');
            $stmt->bindParam(':id', $result['id'], PDO::PARAM_INT);
            $stmt->bindParam(':last', $time, PDO::PARAM_INT);
            $stmt->bindParam(':n', $num, PDO::PARAM_INT);

            $stmt->execute();
        } else {
            $num = 1;

            // insert into the table
            $stmt = $pdo->prepare('INSERT INTO exceptions(class, message, file, line, backtrace, last_throw_at, thrown_num) VALUES(:c, :m, :f, :l, :b, :last, :num)');
            $stmt->bindParam(':c', $class);
            $stmt->bindParam(':m', $message);
            $stmt->bindParam(':f', $file);
            $stmt->bindParam(':l', $line);
            $stmt->bindParam(':b', $traceString);
            $stmt->bindParam(':last', $time);
            $stmt->bindParam(':num', $num, PDO::PARAM_INT);

            $stmt->execute();
        }

        return $num;
    }

    private function needNotify($num)
    {
        return $num == 1 || ($num % $this->max_notify_num == 0);
    }


    private function renderFile($file, $params)
    {
        $view_file = ExceptionWatcherViewDir . '/' . $file . '.php';

        extract($params);
        ob_start();
        include $view_file;
        $output = ob_get_clean();

        return $output;
    }

    private function mail($subject, $body)
    {
        $subject = $this->email_prefix . $subject;

        // subject's lenght has limit
        // @see http://stackoverflow.com/questions/1592291/what-is-the-email-subject-length-limit
        if (strlen($subject) > 150) {
            $subject = substr($subject, 0, 150) . "...";
        }

        $subject = '=?UTF-8?B?' . base64_encode($subject) . '?=';

        $headers = 'Content-type: text/html; charset=UTF-8' . "\r\n";
        $headers .= 'From: ' . $this->email_from . "\r\n";

        $recipients = is_array($this->exception_recipients) ? implode(';', $this->exception_recipients) : $this->exception_recipients;

        //mail($recipients, $subject, $body, $headers);

        $this->sendMail($recipients, '', $subject, $body);

    }

    private function sendMail($to, $cc, $title, $body, $type = 'text/html')
    {
        /*{{{*/
        $config = Yii::app()->config->get('workflow_process')->get('notify');
        $args = array(
            'username' => $config->get('smtp_username')->content,
            'password' => $config->get('smtp_password')->content,
            'handler_email' => $config->get('handler_email')->content,
        );
        Mailer::send($to, $cc, $title, $body, null, $type, array(), $args);
        /*}}}*/
    }

    public function enable()
    {
        $this->enable = true;
    }

    public function disable()
    {
        $this->enable = false;
    }

    public function toggle()
    {
        $this->enable = ($this->enable ? false : true);
    }

    /**
     * Set up the sqlite database
     * create the database if no tables avabile then create
     * those tables
     */
    protected function setUpDb()
    {
        if (!$this->dbFileDir) {
            $this->dbFileDir = Yii::app()->getRuntimePath();
        }

        if ($this->autoCreateExceptionTable && !$this->tableCreated()) {
            $this->createTables();
        }
    }

    public function watchParam($key, $value)
    {
        $this->extra_params[$key] = $value;
    }


    protected function createTables()
    {
        try {
            $dbh = $this->getPDO();
            $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

            $driver = $dbh->getAttribute(PDO::ATTR_DRIVER_NAME);
            if ($driver === 'mysql') {
                $id_str = 'id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY';
            } else if ($driver === 'pgsql') {
                $id_str = 'id SERIAL PRIMARY KEY';
            } else {
                $id_str = 'id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT';
            }

            // Create tables
            $dbh->exec("
				CREATE TABLE exceptions(
					$id_str,
					class VARCHAR(256) NOT NULL,
					message VARCHAR(256) NULL,
					file  VARCHAR(256) NULL,
					line  VARCHAR(256) NULL,
					backtrace TEXT NULL,
					last_throw_at DATETIME,
					thrown_num INT NULL
				);
				");
        } catch (PDOException $e) {
            //@unlink($this->dbFileDir . DIRECTORY_SEPARATOR . $this->dbFileName);
            throw $e;
        }
    }

    protected function tableCreated()
    {
        //return file_exists($this->dbFileDir . DIRECTORY_SEPARATOR . $this->dbFileName);
        try {
            $dbh = $this->getPDO();
            $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
            $sql = "delete from exceptions where 0=1";
            $ret = $dbh->exec($sql);
            return true;
        } catch (Exception $e) {
            return false;
        }
    }

}

/**
 * Used to indentify ower own exceptions
 * So we can set the Exception handler to ignore
 * Exception Notification's web interface's exceptions
 */
class ExceptionWatcherException extends Exception
{
}
